---------------------------------------------------------------------------------------
----------  Proliphix thermostat API wrapper
----------
----------  This script manages communication with an individual thermostat.
----------  This module is responsible for bridging the gap between
----------  the Proliphix API and the NetStreams HVAC interface. It has knowledge of
----------  the APIs for both systems.
----------
----------  This module has some assumptions:
----------    It assumes that the external temperature sensor is sensor 1. This can be
----------      changed by working with the code that touches SENSOR_TEMP_1
----------    It assumes that the thermostat is reporting the temperature, rather than
----------      an external sensor. This can be changed by working with the code that
----------      touches SENSOR_TEMP_LOCAL
----------    If using the proliphix ASP driver, it is named 'proliphix.asp' and is
----------      located at the root of the Windows XP web server.
----------
----------  Written by: Josh Kewley
----------              Insario Corporation
----------  Version:    0.61
----------  Date: 09/04/2007
---------------------------------------------------------------------------------------
local URL = require "url"
local HTTP = require "http"

module(...,package.seeall)

RevisionNumber="0.63"

-- These variables, though declared local, are available to all instances of ProliphixAdapter
local doDebug = true             -- Setting debug to true uses the ASP driver. Refer to the header in pro.lua or the ReadMe for more information.
local sendTimer = nil             -- The timer that is responsible for kicking off the sending of the commands to the thermostat
local sendLock = false            -- Used to lock the queue when iterating the collection during a send
local timeBetweenSets = 5*1000    -- SET commands are added to a queue and sent in batches. This is the length of time that we'll wait
                                  -- between commands being added to the queue, and after the last command is queued before sending.

-- encapsulate debugging
function DBG(self,message)
    if (self and self.myZone) then
        debug("Proliphix", self.myZone.DeviceName or "[no name]", "  ", HTTP.makeReadable(message))
    else
        debug("Proliphix", HTTP.makeReadable(message))
    end
end
function DBGDTL(self,message)
    if (self and self.myZone) then
        debug("ProliphixDetail", self.myZone.DeviceName or "[no name]", "  ", HTTP.makeReadable(message))
    else
        debug("ProliphixDetail", HTTP.makeReadable(message))
    end
end
function printError(...)
	debug("Error", ...)
end

-- Helper method to count the number of elements in a hashtable, which should be easier, but I can't figure out how to do it. I'm waiting for my "duh" moment
function getelementcount(aHashTable)
    local count=0
    for i,v in pairs(aHashTable) do count=tonumber(count) + 1 end
    return count
end

-- Table to store the Proliphix API constants
Proliphix = {
    NetStreams = {
            --HVAC MODE
        ["4.1.1:1"] ="OFF",
        ["4.1.1:2"] ="HEAT",
        ["4.1.1:3"] ="COOL",
        ["4.1.1:4"] ="AUTO",
            --HVAC STATE
        ["4.1.2:1"] ="INIT",
        ["4.1.2:2"] ="OFF",
        ["4.1.2:3"] ="STATEHEAT1",
        ["4.1.2:4"] ="STATEHEAT2",
        ["4.1.2:5"] ="STATEHEAT3",
        ["4.1.2:6"] ="STATECOOL1",
        ["4.1.2:7"] ="STATECOOL2",
        ["4.1.2:8"] ="DELAY",
        ["4.1.2:9"] ="RESET",
            --FAN MODE
        ["4.1.3:1"] = "AUTO",
        ["4.1.3:2"] = "ON",
        ["4.1.3:3"] = "AUTO",
            --FAN STATE
        ["4.1.4:0"] = "0",--"OFF",
        ["4.1.4:1"] = "0",--"OFF",
        ["4.1.4:2"] = "1",--"ON",
    },
    Constants = {
        MODE="4.1.1",
          MODEHEAT="2",
          MODECOOL="3",
          MODEAUTO="4",
        STATE="4.1.2",
          STATEHEAT1="3",
          STATEHEAT2="4",
          STATEHEAT3="5",
          STATECOOL1="6",
          STATECOOL2="7",
        FANMODE="4.1.3",
        FANSTATE="4.1.4",
        SETBACKHEAT="4.1.5",
        SETBACKCOOL="4.1.6",
        SENSOR_TEMP_LOCAL="4.3.2.1",
        SENSOR_TEMP_1="4.3.2.2",
        RELATIVE_HUMIDITY="4.1.14",
        DEVICE_NAME="1.2",
    }
}
-- variable that holds the full status query
local queryBody = "ACTION=get&OID"..Proliphix.Constants.DEVICE_NAME.."=&OID"..Proliphix.Constants.MODE.."=&OID"..Proliphix.Constants.STATE.."=&OID"..Proliphix.Constants.FANMODE.."=&OID"..Proliphix.Constants.FANSTATE.."=&OID"..Proliphix.Constants.SETBACKHEAT.."=&OID"..Proliphix.Constants.SETBACKCOOL.."=&OID"..Proliphix.Constants.SENSOR_TEMP_LOCAL.."=&OID"..Proliphix.Constants.SENSOR_TEMP_1.."=&OID"..Proliphix.Constants.RELATIVE_HUMIDITY.."="

-- Use myZone to pass update information into the HVAC interface
function UpdateDisplay(self)
    self:DBG("Updating the display")
    self.myZone:setStatus("current", self.myZone.CurrentSetpoint or "--")
    self.myZone:setStatus("temperature", self.myZone.Temp or "--")
    self.myZone:setStatus("heat", self.myZone.HeatSetBack or "--")
    self.myZone:setStatus("cool", self.myZone.CoolSetBack or "--")
    self.myZone:setStatus("mode", self.myZone.Mode or "AUTO")
    self.myZone:setStatus("fanMode", self.myZone.FanMode or "AUTO")
    self.myZone:setStatus("fan", self.myZone.FanState or 0)
    self.myZone:setStatus("outdoorTemperature", self.myZone.RemoteTemp or "--") --//BUG: This doesn't appear to work
    self.myZone:setStatus("humidity", self.myZone.Humidity or "--") --//BUG: This doesn't appear to work
    self.myZone:setStatus("outdoorHumidity", self.myZone.Temp or "--") --//BUG: This doesn't appear to work
end

-- Proliphix numbers are reported without a decimal point (95.5 degrees = '955')
function ProliphixNumberToNetstreams(aProliphixTemp)
    return tonumber(aProliphixTemp)/10
end

-- This is the current setpoint. It reflects the cool setback when cooling, the heat one if heating, the current state if on auto
-- Defaults to cooling if on auto and not doing anything (I live in Arizona)
function GetCurrentSetpoint(self)
  if(self.myZone.Mode == "COOL") then
    return self.myZone.CoolSetBack
  elseif(self.myZone.Mode == "HEAT") then
    return self.myZone.HeatSetBack
  elseif(self.myZone.Mode == "AUTO") then
    -- if cooling, return cool set point, if heating, return heat set point, otherwise return default of cool set point
    if(self.myZone.State==Proliphix.Constants.STATEHEAT1 or self.myZone.State==Proliphix.Constants.STATEHEAT2) then
      return self.myZone.HeatSetBack
    elseif(self.myZone.State==Proliphix.Constants.STATEHEAT3) then
      --special case of Auxiliry Heat
      setStatus("mode", "EMHT")
      return self.myZone.HeatSetBack
    else
      -- either cool setting, off, init, reset, or delay. Default to cool set point
      return self.myZone.CoolSetBack
    end  
  else
    -- system is off
    self:DBG("Get Current Setpoint with mode "..(self.myZone.Mode or "MISSING"))
    return self.myZone.Mode
  end
  
end

-- sets the zone properties based upon the values in the status table
-- this method is called after a refresh of data occurs
function UpdateDeviceStaus(self,statusTable)
    self:DBG("Updating device status")
    local var

    var = statusTable["OID"..Proliphix.Constants.MODE]
    self:DBGDTL("Mode is "..(var or "MISSING"))
    if(var) then
        self.myZone.Mode = Proliphix.NetStreams[Proliphix.Constants.MODE..":"..var]
        if(self.myZone.Mode) then self:DBG("Mode ["..var.. "] is "..self.myZone.Mode) end
    end

    var = statusTable["OID"..Proliphix.Constants.STATE]
    self:DBGDTL("State is "..(var or "MISSING"))
    if(var) then
        self.myZone.State = Proliphix.NetStreams[Proliphix.Constants.STATE..":"..var]
        if(self.myZone.State) then self:DBG("State ["..var.. "] is "..self.myZone.State) end
    end

    var = statusTable["OID"..Proliphix.Constants.FANMODE]
    self:DBGDTL("Fan mode is "..(var or "MISSING"))
    if(var) then
        self.myZone.FanMode = Proliphix.NetStreams[Proliphix.Constants.FANMODE..":"..var]
        if(self.myZone.FanMode) then self:DBG("FanMode ["..var.. "] is "..self.myZone.FanMode) end
    end

    var = statusTable["OID"..Proliphix.Constants.FANSTATE]
    self:DBGDTL("Fanstate is "..(var or "MISSING"))
    if(var) then
        self.myZone.FanState = Proliphix.NetStreams[Proliphix.Constants.FANSTATE..":"..var]
        if(self.myZone.FanState) then self:DBG("FanState ["..var.. "] is "..self.myZone.FanState) end
    end

    var = statusTable["OID"..Proliphix.Constants.SENSOR_TEMP_LOCAL] --ASSUMPTION: Local reports temp, not sensor
    self:DBGDTL("Sensor temp local is "..(var or "MISSING"))
    if(var) then
        self.myZone.Temp = ProliphixNumberToNetstreams(var)
        if(self.myZone.Temp) then self:DBG("Temp ["..var.. "] is "..self.myZone.Temp) end
    end

    var = statusTable["OID"..Proliphix.Constants.SENSOR_TEMP_1] --ASSUMPTION: Sensor 1 reports external temp
    self:DBGDTL("Sensor temp 1 is "..(var or "MISSING"))
    if(var) then
        if (var=="FAILED5") then
            self.myZone.RemoteTemp = "--"
            self:DBG("This version of Proliphix thermostat doesn't support remote sensors")
        else
            self.myZone.RemoteTemp = ProliphixNumberToNetstreams(var)
            if(self.myZone.RemoteTemp) then self:DBG("RemoteTemp ["..var.. "] is "..self.myZone.RemoteTemp) end
        end
    end

    var = statusTable["OID"..Proliphix.Constants.RELATIVE_HUMIDITY]
    self:DBGDTL("Humidity is "..(var or "MISSING"))
    if(var) then
        if (var=="FAILED5") then
            self.myZone.RemoteTemp = "--"
            self:DBG("This version of Proliphix thermostat doesn't support relative humidity")
        else
            self.myZone.Humidity = var
            if(self.myZone.Humidity) then self:DBG("Humidity ["..var.. "] is "..self.myZone.Humidity) end
        end
    end

    var = statusTable["OID"..Proliphix.Constants.SETBACKCOOL]
    self:DBGDTL("Setback cool is "..(var or "MISSING"))
    if(var) then
        self.myZone.CoolSetBack = ProliphixNumberToNetstreams(var)
        if(self.myZone.CoolSetBack) then self:DBG("CoolSetBack ["..var.. "] is "..self.myZone.CoolSetBack) end
    end

    var = statusTable["OID"..Proliphix.Constants.SETBACKHEAT]
    self:DBGDTL("Setback heat is "..(var or "MISSING"))
    if(var) then
        self.myZone.HeatSetBack = ProliphixNumberToNetstreams(var)
        if(self.myZone.HeatSetBack) then self:DBG("HeatSetBack ["..var.. "] is "..self.myZone.HeatSetBack) end
    end

    var = statusTable["OID"..Proliphix.Constants.DEVICE_NAME]
    self:DBGDTL("Device name is "..(var or "MISSING"))
    if(var) then
        self.myZone.DeviceName = var
        if(self.myZone.DeviceName) then self:DBG("DeviceName ["..var.. "] is "..self.myZone.DeviceName) end
    end

    var = GetCurrentSetpoint(self)
    self:DBGDTL("Current Setpoint is "..(var or "MISSING"))
    self.myZone.CurrentSetpoint = var
    

    self:UpdateDisplay()
end

-- sends the list of set commands to a Proliphix thermostat. Waits a second to ensure that no update has been made before sending the cmd
function SendQueuedCommands(self)
    local cmdCount = getelementcount(self.myZone.cmdsToSend)
    self:DBGDTL("Send queue is "..cmdCount.." in length")
    if (cmdCount==0) then
        return nil
    end
    if (self.sendLock==true) then
        self:DBG("Trying to send the queue while the data is being refreshed by an outside timer. Skip the send for now.")
        return nil
    end
    -- lock the command queue while we iterate through it and send off the values
    self.sendLock = true

    local queryBody = "ACTION=pdp&"
    -- extract all values
    for i, v in pairs(self.myZone.cmdsToSend) do
        queryBody = queryBody.."OID"..i.."="..v.."&"
        self.myZone.cmdsToSend[i]=nil
    end
    queryBody = queryBody.."submit=Submit"  -- Proliphix API oddity
    self:DBGDTL("Post Command : "..queryBody)

    local response = {}
          response.body, 
          response.headers, 
          response.code, 
          response.error = HTTP.Post({
                                      url = (self.myZone.deviceURL..self.setPath),
                                      user = self.myZone.userName,
                                      pass = self.myZone.userPass,
                                      body = queryBody})
    self.sendLock = false

    -- reponse from thermostat is in querystring format OIDname=value
    local statusTable = URL.QuerystringToTable(response.body)
    self:UpdateDeviceStaus(statusTable)
    --response=nil
    --statusTable=nil
end

-- takes a command from the HVAC interface and adds it to the queue of commands that should be sent next time
-- resets the send timer in case the user is issuing multiple commands through the interface
function QueueSetCommand(self, cmd, newValue)
    self:DBG("Queue command "..cmd.." --> "..newValue)
    -- see if we're currently in the process of sending or refreshing values. If so, wait until done.
    while(self.sendlock==true) do
        self:DBG("Queue locked during update post")
        os.sleep(1500)
    end
    -- this will overwrite the value if it's already there
    self.myZone.cmdsToSend[cmd] = newValue
    -- reset timer
    self.sendTimer:queue(timeBetweenSets)
end

-- Table of functions that handle the many #SET commands that come from the HVAC interface
-- First we update the status table, then we call UpdateDisplay() to give immediate response to the user, then we queue the command
local SetCommands = {
   scale = function(self,zone, cmd)
        -- #SET SCALE F|C|K
        self:DBG("This driver version does not handle scale changes")
   end,

   heat = function(self,cmd)
        local temp = cmd.params[2]
        local increment = cmd.params[3] or 1
        if(self.myZone.HeatSetBack) then
            -- don't accept commands unless the data is available
            self:DBG("HEAT method called "..temp)
            if (temp == "up") then temp = self.myZone.HeatSetBack + increment
            elseif (temp == "down") then temp = self.myZone.HeatSetBack - increment
            else temp = tonumber(temp)
    	    end

            --enforce maximum and minimum settings, as required by the Proliphix API
    	    if(temp > 95) then temp = 95 end
    	    if(temp < 45) then temp = 45 end

    	    self.myZone.HeatSetBack = temp
        end
        
      	self:UpdateDisplay()
      	self.QueueSetCommand(self, Proliphix.Constants.SETBACKHEAT, temp*10)
   end,

   cool = function(self,cmd)
        local temp = cmd.params[2]
        local increment = cmd.params[3] or 1
        if (self.myZone.CoolSetBack) then
            -- don't accept commands unless data is available
            self:DBG("COOL method called "..temp)
            if (temp == "up") then temp = self.myZone.CoolSetBack + increment
            elseif (temp == "down") then temp = self.myZone.CoolSetBack - increment
            else temp = tonumber(temp)
    	    end

            --enforce maximum and minimum settings, as required by the Proliphix API
    	    if(temp > 95) then temp = 95 end
    	    if(temp < 45) then temp = 45 end

    	    self.myZone.CoolSetBack = temp
        end
        
      	self:UpdateDisplay()
      	self.QueueSetCommand(self, Proliphix.Constants.SETBACKCOOL, temp*10)
   end,

   current = function(self,cmd)
        local temp = cmd.params[2]
        local increment = cmd.params[3] or 1

        -- TODO: Need to better understand what this function is supposed to do
        self:DBG("CURRENT method called "..temp)
        if (temp == "up") then temp = self.myZone.Temp + increment
        elseif (temp == "down") then temp = self.myZone.Temp - increment
        else temp = tonumber(temp)
    	end

        --enforce maximum and minimum settings, as required by the Proliphix API
	    if(temp > 95) then temp = 95 end
	    if(temp < 45) then temp = 45 end

	    self.myZone.Temp = temp
	    self:UpdateDisplay()
	    --self.QueueSetCommand(self, Proliphix.Constants.SETBACKHEAT, temp)
    end,

    mode = function(self,cmd)
        -- Implement #SET MODE EHEAT|HEAT|COOL|AUTO|OFF
        local mode = cmd.params[2]:upper()
        local modeValue -- value required by the NetStreams HVAC interface

        self:DBG("MODE method called "..mode)
        if(mode == "EHEAT") then
            modeValue = 5 --Proliphix API called this auxilary heat
        elseif (mode == "OFF") then
            modeValue = 1
            self.myZone.FanState=0
            self.myZone:setStatus("fan", 0) --fake the immediate fan shutdown
        elseif (mode == "HEAT") then
            modeValue = 2
        elseif (mode == "COOL") then
            modeValue = 3
        elseif (mode == "AUTO") then
            modeValue = 4
        else
            -- Check for other valid operating modes
            printError("Unknown operating mode: ", modeValue)
            return
        end
        
        -- Update the zone(s) with the new operating mode
        self.myZone.Mode = mode
	      self:UpdateDisplay()
        self.QueueSetCommand(self, Proliphix.Constants.MODE, modeValue)
    end,

   fanmode = function(self,cmd)
        --  Implement #SET FANMODE ON|AUTO
        --	Note that the HVAC interface doesn't have a way to turn the fan off.
        --  Instead you must turn the unit off, using #SET MODE OFF.
        local mode = cmd.params[2]:upper()
        local fanValue -- value required by the NetStreams HVAC interface

        self:DBG("FANMODE method called "..mode)
        if(mode == "ON") then
            fanValue  = 2
            self.myZone.FanState=1
            self.myZone:setStatus("fan", 1) --fake the immediate fan fire up
        elseif (mode == "AUTO") then
            fanValue = 1
        else
          printError("Unknown fan mode: ", mode)
          return
        end

    	self.myZone.FanMode = mode
    	self:UpdateDisplay()
    	self.QueueSetCommand(self, Proliphix.Constants.FANMODE, fanValue)
   end
}

-- creates a NetStreams subNode, which is associated with this specific thermostat
-- this 'zone' will send our driver messages from the HVAC interface, which we will then
-- use to look up a proper method in this driver to call.
function HookZone(self, zoneId)
    if(subNodes["zone"..zoneId] == nil) then
        self:DBG("Creating SubNode: ".. zoneId)

        -- get StreamNet to create the underlying zone and functions
        local zone = createSubNode("zone"..zoneId)
              zone.address = zoneId
              zone.Adapter = self

        -- define the handler for the #set command
        function zone:handle_set(pCmd)
            self.Adapter:DBG("Handle #set for "..table.concat(pCmd.params, " "))
            local method = SetCommands[pCmd.params[1]:lower()] -- look up function

            if (type(method) == "function") then
                self.Adapter:DBGDTL("Calling SET function")
                method(self.Adapter, pCmd) --and call it
            else
                printError("Unknown #set command: ", pCmd.params[1])
            end
        end

        function zone:default_handle_command(pCmd)
            self.Adapter:DBG("Trapped unhandled command", pCmd.command, table.concat(pCmd.params, ","), pCmd.to or "nil", pCmd.from or "nil")
        end

        function zone:__tostring()
            -- TODO: replace the tostring() metamethod
            return zone.DeviceName.."    "..zone.Mode.."    "..zone.Status.."    "..zone.FanMode.."    "..zone.Temp.."    "..zone.RemoteTemp.."    "..zone.Humidity.."    "..zone.CoolSetBack.."    "..zone.HeatSetBack
        end
    end

    return subNodes["zone"..zoneId]
end

function PauseQueue(self)
  self:DBG("Send queue paused")
  self.sendTimer:cancel()
end

function ResumeQueue(self)
  self:DBG("Send queue resumed")
  self.sendTimer:queue(timeBetweenSets)
end

-- POST a request to the Proliphix device asking for the current numbers
-- This is called by pro.lua regularly. It is possible that it might be called when
-- the queue is being sent, because an external timer kicks off the refresh.
function RefreshData(self)
    -- don't refresh if there are commands in the queue. Those commands will be sent soon, and the UI will be refreshed.
    local cmdCount = getelementcount(self.myZone.cmdsToSend)
    self:DBG("Send queue is "..cmdCount.." in length")
    if (cmdCount>0) then
        self:DBG("No need to refresh - commands in the queue")
        return nil
    end
    if (self.sendLock==true) then
        self:DBG("Trying to refresh while the queue is being sent. Abandon refresh.")
        return nil
    end

    self.sendLock = true
    self:DBG("Refreshing all data")
    
    local response = {}
          response.body, 
          response.headers, 
          response.code, 
          response.error = HTTP.Post({
                                      url = (self.myZone.deviceURL..self.getPath),
                                      user = self.myZone.userName,
                                      pass = self.myZone.userPass,
                                      body = queryBody})
    self.sendLock = false
    if(response.error~=nil or response.code==nil) then
      self:DBG("Error during data refresh. Try again later.")
      return response
    end
    if (response.code~=200) then 
      self:DBG("Refresh returned HTTP status code "..response.code)
      if (response.body) then self:DBG("Response body is "..response.body) end
      return response
    end
    if (response.body) then self:DBGDTL("Response is "..response.body) end
    local statusTable = URL.QuerystringToTable(response.body)
    self:UpdateDeviceStaus(statusTable)
    --response=nil
    --statusTable = nil
end

-- Initialize the zone and hook it into a NetStreams subNode
-- store all zone-specific variables on the zone itself.
function Init(self, IPAddress, user, pass, zoneIndex)
    self.myZone = self:HookZone(zoneIndex) -- Represents the DigiLinx zone that this ProliphixAdapter instance is tied to
    self.myZone.userName = user
    self.myZone.userPass = pass
    self.myZone.cmdsToSend = {} -- Used as a queue for commmands to be sent to the thermostat
    self.myZone.DeviceName = IPAddress
    if (doDebug) then --ASSUMPTION: proliphix ASP driver naming and path convention
        self.myZone.deviceURL = "http://"..IPAddress.."/proliphix.asp"
        self.getPath=""
        self.setPath=""
    else
        self.myZone.deviceURL = "http://"..IPAddress
        self.getPath="/get"
        self.setPath="/set"
    end
    self:DBG("Zone hooked for thermostat "..IPAddress)
    -- send the command queue to the thermostat in 'timeBetweenSets' seconds. This allows for more than one control to be sent at once.
    self.sendTimer = createTimer(timeBetweenSets, function(timer)
                                            self:SendQueuedCommands()
                                            return timeBetweenSets
                                        end)
end

-- TODO: replace the tostring() metamethod for this module
function __tostring(self)
    return self.myZone.DeviceName.." : "..self.myZone.deviceURL
end

-- Entry point to this module. Creates a new, self managed adapter
function new(self)
    local instance = {}
    setmetatable(instance, self)
    self.__index = self
		return instance
end